# This file is part of Appartition, a lightweight and portable backup tool.
# Copyright (C) 2012-2013 Simon Grieger
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

###########################################################################
# Class: Appartition::FileSystem
#
# Objects of this class store file system information. They allow file
# systems to be mounted and unmounted.
###########################################################################

package Appartition::FileSystem;

use 5.006;
use strict;
use warnings;

use Appartition;
use Appartition::Core;
use Appartition::Shell;
use Appartition::Utilities;

###########################################################################
# Group: Class Variables
###########################################################################

# Integer: STAMP
#
# This variable is used to stamp file systems. Initially, it is set to
# zero. Every time a file system is mounted, after having stamped the file
# system, the variable will be increased. See <stamp()> and <mount()>.
my $STAMP = 0;

###########################################################################
# Group: Constructors
###########################################################################

# Constructor: new
#
# Creates an object of class Appartition::FileSystem from the given file
# system specification, mount point, and additional information.
#
# Parameters:
#
#   spec - File system. May be the path to a device file or a specification
#          like LABEL=<label> or UUID=<uuid>. Note that lower-case
#          characters should be used in UUIDs.
#   mp   - Mount point.
#   type - File system type (or undef).
#   opts - Reference to an array of mount options (or undef). Options may
#          have values, as in "size=2g". Use
#          <Appartition::Utilities::split_mount_options()> to split a
#          comma-separated list.
#
# Returns:
#
#   An object of class Appartition::FileSystem.
sub new #(spec, mp, type, opts)
  {
    my $class = shift;
    my ($spec, $mp, $type, $opts) = @_;

    # Set default options if none specified.
    $opts = [Appartition::Utilities::split_mount_options($Appartition::OPTION_VALUES->{'mount-options'})]
      if (not $opts);

    my $self = {
                'spec'          => undef,
                'mount_point'   => Appartition::Utilities::strip_trailing_slashes($mp),
                'type'          => defined $type ? $type : 'auto',
                'options'       => {},
                'dump'          => 0,
                'pass'          => 0,

                'device'        => undef,
                'label'         => undef,
                'uuid'          => undef,

                'is_selected'   => 0,
                'is_mounted'    => 0,
                'needs_remount' => 0,

                'id'            => undef,
                'stamp'         => undef,
               };

    bless($self, $class);

    # Set file system spec, device, label, and UUID, as appropriate. Add
    # the specified mount options.
    $self->spec($spec);
    $self->options($opts);

    return $self;
  }

# Constructor: new_from_fstab_entry
#
# Creates an object of class Appartition::FileSystem from an fstab entry.
#
# Parameters:
#
#   entry - Entry/line from an fstab, with all comments and
#           leading/trailing whitespace stripped.
#
# Returns:
#
#   An object of class Appartition::FileSystem.
sub new_from_fstab_entry #(entry)
  {
    my $class = shift;
    my @fields = split('\s+', shift);
    die 'Invalid fstab entry' . "\n" if @fields != 6;
    my ($spec, $mp, $type, $opts, $dump, $pass) = @fields;

    Appartition::Shell::show_debug_output('Read fs info:' . "\n",
                                          '  spec        = "' . $spec . '"' . "\n",
                                          '  mount_point = "' . $mp . '"' . "\n",
                                          '  type        = "' . $type . '"' . "\n",
                                          '  options     = "' . $opts . '"' . "\n",
                                          '  dump        = "' . $dump . '"' . "\n",
                                          '  pass        = "' . $pass . '"');

    # TODO: Set dump and pass.
    return $class->new($spec, $mp, $type, [Appartition::Utilities::split_mount_options($opts)]);
  }

###########################################################################
# Group: Accessor and Mutator Methods
###########################################################################

# Method: spec
#
# Gets or sets the file system specification. In the latter case, the file
# system's device file, label, and/or UUID will also be updated, as
# appropriate. Note that setting a mounted file system's specification will
# be rejected.
#
# Parameters:
#
#   spec - File system. May be the path to a device file or a specification
#          like LABEL=<label> or UUID=<uuid>. Note that lower-case
#          characters should be used in UUIDs. Omit this parameter (or pass
#          undef) if you do not want to set the file system specification,
#          but just retrieve it.
#
# Returns:
#
#   The file system specification.
sub spec #(spec)
  {
    my $self = shift;
    my ($spec) = @_;

    if (defined $spec) {
      die 'File system is mounted' . "\n" if ($self->is_mounted());
      $self->{'spec'} = $spec;
      $self->resolve_spec();
    }

    return $self->{'spec'};
  }

# Method: mount_point
#
# Gets or sets the file system's mount point. Note that setting a mounted
# file system's mount point will be rejected.
#
# Parameters:
#
#   mp - Mount point. Omit this parameter (or pass undef) if you do not
#        want to set the mount point, but just retrieve it.
#
# Returns:
#
#   The file system's mount point.
sub mount_point #(mp)
  {
    my $self = shift;
    my ($mp) = @_;

    if (defined $mp) {
      die 'File system is mounted' . "\n" if ($self->is_mounted());
      $self->{'mount_point'} = Appartition::Utilities::strip_trailing_slashes($mp);
    }

    return $self->{'mount_point'};
  }

# Method: effective_mount_point
#
# Translates the file system's mount point to the directory where the file
# system will actually be mounted.
#
# Returns:
#
#   The file system's effective mount point.
sub effective_mount_point #()
  {
    my $self = shift;
    my $mp = $self->mount_point();

    die 'Mount point of file system "' . $self->spec() . '" does not have a leading slash' . "\n"
      unless ($mp =~ m'^/'o);

    return Appartition::Utilities::prepend_workspace($mp);
  }

# Method: type
#
# Gets or sets the file system's type. Note that setting a mounted file
# system's type will be rejected.
#
# Parameters:
#
#   type - File system type. Omit this parameter (or pass undef) if you do
#          not want to set the type, but just retrieve it.
#
# Returns:
#
#   The file system's type.
sub type #(type)
  {
    my $self = shift;
    my ($type) = @_;

    if (defined $type) {
      die 'File system is mounted' . "\n" if ($self->is_mounted());
      $self->{'type'} = $type;
    }

    return $self->{'type'};
  }

# Method: options
#
# Gets or sets the file system's mount options. Setting a mounted file
# system's options is allowed; you will have to call <remount()> for the
# changes to take effect, though. See <needs_remount()> and <remount()>.
#
# Parameters:
#
#   opts - Reference to an array of mount options. Options may have values,
#          as in "size=2g". Omit this parameter (or pass undef) if you do
#          not want to set the options, but just retrieve them.
#
# Returns:
#
#   A list of the file system's mount options.
sub options #(opts)
  {
    my $self = shift;
    my ($opts) = @_;

    if (defined $opts) {
      %{$self->{'options'}} = ();
      $self->{'needs_remount'} = 1 if ($self->is_mounted());

      # Add all options.
      foreach (@$opts) {
        my ($o, $v) = Appartition::Utilities::resolve_mount_option($_);
        $self->{'options'}{$o} = $v;
      }
    }

    my @list = ();

    # Generate a list of all options with their values.
    foreach my $o (sort keys %{$self->{'options'}}) {
      unless (defined $self->{'options'}{$o}) {
        push(@list, $o);
      }
      else {
        push(@list, $o . '=' . $self->{'options'}{$o});
      }
    }

    return @list;
  }

# Method: has_option
#
# Checks whether an option is set for the file system.
#
# Parameters:
#
#   opt - Option to check for. Note that, if you pass an option with a
#         value (as in "size=2g"), the value will be ignored.
#
# Returns:
#
#   True if the option is set, false otherwise.
sub has_option #(opt)
  {
    my $self = shift;
    my ($opt, undef) = Appartition::Utilities::resolve_mount_option(shift);

    return exists $self->{'options'}{$opt};
  }

# Method: set_option
#
# Adds an entry to the file system's list of mount options. If such an
# entry already exists, it will be overwritten, possibly changing the
# option's previous value. Conflicting options will be removed
# automatically. Modifying a mounted file system's options is allowed; you
# will have to call <remount()> for the changes to take effect, though. See
# <needs_remount()> and <remount()>.
#
# Parameters:
#
#   opt - Option to set. The option may have a value, as in "size=2g".
#
# Returns:
#
#   True.
sub set_option #(opt)
  {
    my $self = shift;
    my ($opt, $val) = Appartition::Utilities::resolve_mount_option(shift);

    # We want to avoid setting the given option if exactly the same one
    # already exists. Thus, we only set the option in one of the following
    # cases:
    #
    # 1. The option does not exist yet.
    # 2. The old option does not have a value, but the new one does, or
    #    vice versa.
    # 3. They both have a value, but the values differ.

    if ((not exists $self->{'options'}{$opt}) or
        (defined $self->{'options'}{$opt} xor defined $val) or
        (defined $self->{'options'}{$opt} and defined $val and $self->{'options'}{$opt} ne $val)) {
      # Remove conflicting options.
      foreach (Appartition::Utilities::get_conflicting_mount_options($opt)) {
        delete $self->{'options'}{$_};
      }

      $self->{'options'}{$opt} = $val;
      $self->{'needs_remount'} = 1 if ($self->is_mounted());
    }

    return 1;
  }

# Method: unset_option
#
# Removes an entry from the file system's list of mount options. Modifying
# a mounted file system's options is allowed; you will have to call
# <remount()> for the changes to take effect, though. See <needs_remount()>
# and <remount()>.
#
# Parameters:
#
#   opt - Option to unset. Note that, if you pass an option with a value
#         (as in "size=2g"), the value will be ignored.
#
# Returns:
#
#   True.
sub unset_option #(opt)
  {
    my $self = shift;
    my ($opt, undef) = Appartition::Utilities::resolve_mount_option(shift);

    if (exists $self->{'options'}{$opt}) {
      delete $self->{'options'}{$opt};
      $self->{'needs_remount'} = 1 if ($self->is_mounted());
    }

    return 1;
  }

# Method: device
#
# Gets or sets the file system's device file. Note that setting a mounted
# file system's device file will be rejected. As this method will not
# update the file system specification, also consider using <spec()> to
# modify the device file.
#
# Parameters:
#
#   dev - Device file. Omit this parameter (or pass undef) if you do not
#         want to set the device file, but just retrieve it.
#
# Returns:
#
#   The file system's device file (or undef if the device file could not be
#   resolved for some reason).
sub device #(dev)
  {
    my $self = shift;
    my ($dev) = @_;

    if (defined $dev) {
      die 'File system is mounted' . "\n" if ($self->is_mounted());
      $self->{'device'} = $dev;
    }

    return $self->{'device'};
  }

# Method: label
#
# Gets or sets the file system's label. Note that setting a mounted file
# system's label will be rejected. As this method will not update the file
# system specification, also consider using <spec()> to modify the label.
#
# Parameters:
#
#   lb - Label. Omit this parameter (or pass undef) if you do not want to
#        set the label, but just retrieve it.
#
# Returns:
#
#   The file system's label (or undef if the file system has no label).
sub label #(lb)
  {
    my $self = shift;
    my ($lb) = @_;

    if (defined $lb) {
      die 'File system is mounted' . "\n" if ($self->is_mounted());
      $self->{'label'} = $lb;
    }

    return $self->{'label'};
  }

# Method: uuid
#
# Gets or sets the file system's UUID. Note that setting a mounted file
# system's UUID will be rejected. As this method will not update the file
# system specification, also consider using <spec()> to modify the UUID.
#
# Parameters:
#
#   id - UUID. Note that lower-case characters should be used in
#        UUIDs. Omit this parameter (or pass undef) if you do not want to
#        set the UUID, but just retrieve it.
#
# Returns:
#
#   The file system's UUID (or undef if the file system has no UUID).
sub uuid #(id)
  {
    my $self = shift;
    my ($id) = @_;

    if (defined $id) {
      die 'File system is mounted' . "\n" if ($self->is_mounted());
      $self->{'uuid'} = $id;
    }

    return $self->{'uuid'};
  }

# Method: is_selected
#
# Checks whether the file system is selected.
#
# Returns:
#
#   True if the file system is selected, false otherwise.
sub is_selected #()
  {
    my $self = shift;
    return $self->{'is_selected'};
  }

# Method: select
#
# Selects the file system.
#
# Returns:
#
#   True.
sub select #()
  {
    my $self = shift;
    return $self->{'is_selected'} = 1;
  }

# Method: deselect
#
# Deselects the file system.
#
# Returns:
#
#   False.
sub deselect #()
  {
    my $self = shift;
    return $self->{'is_selected'} = 0;
  }

# Method: is_mounted
#
# Checks whether the file system is mounted.
#
# Returns:
#
#   True if the file system is mounted, false otherwise.
sub is_mounted #()
  {
    my $self = shift;
    return $self->{'is_mounted'};
  }

# Method: needs_remount
#
# Checks whether the file system needs to be remounted. This is the case if
# you modified its options while mounted. See <options()>, <set_option()>,
# and <unset_option()>.
#
# Returns:
#
#   True if the file system needs a remount, false otherwise.
sub needs_remount #()
  {
    my $self = shift;
    return $self->{'needs_remount'};
  }

# Method: id
#
# Gets or sets the file system's ID. See <Appartition::FILE_SYSTEMS> for a
# description of file system IDs. *Warning:* Do not set the ID yourself! It
# will be set by <Appartition::Core> when the file system is added to the
# list of known file systems, and cleared when it is removed. Assigning an
# ID manually will break the list of known file systems. As a safety
# measure, setting the ID will be rejected if the file system already has
# one. Thus, the ID needs to be cleared before assignment.
#
# Parameters:
#
#   id - File system ID. A negative value will clear the ID. Omit this
#        parameter (or pass undef) if you do not want to set the ID, but
#        just retrieve it.
#
# Returns:
#
#   The file system's ID, or undef if the file system has not been assigned
#   an ID.
sub id #(id)
  {
    my $self = shift;
    my ($id) = @_;

    if (defined $id) {
      unless ($id < 0) {
        die 'File system "' . $self->spec() . '" already has an ID' . "\n"
          if (defined $self->{'id'});
        $self->{'id'} = $id;
      }
      else {
        $self->{'id'} = undef;
      }
    }

    return $self->{'id'};
  }

# Method: stamp
#
# Gets the file system's stamp. The stamp is a non-negative integer,
# assigned to the file system when it is mounted. Given two file systems,
# the one with the smaller stamp was mounted earlier. No two file systems
# carry the same stamp. This way, it is possible to reliably determine the
# order in which the file systems were mounted. See <mount()>.
#
# Returns:
#
#   The file system's stamp, or undef if the file system is not currently
#   mounted.
sub stamp #()
  {
    my $self = shift;
    return $self->{'stamp'};
  }

###########################################################################
# Group: High-level Methods
###########################################################################

# Method: mount
#
# Mounts the file system.
#
# Returns:
#
#   True.
sub mount #()
  {
    my $self = shift;

    $self->check_mountability();
    $self->check_mount_point();

    Appartition::Shell::show_progress('Mounting "' . $self->mount_point() . '"');
    Appartition::Shell::show_debug_output('File system is "' . $self->spec() . '"');
    Appartition::Shell::show_debug_output('Effective mount point is "' . $self->effective_mount_point() . '"');

    Appartition::Core::exec_mount($self, 0);

    $self->{'is_mounted'} = 1;
    $self->{'needs_remount'} = 0;

    $self->{'stamp'} = $STAMP++;

    return 1;
  }

# Method: mount_read_only
#
# Mounts the file system read-only.
#
# Returns:
#
#   True.
sub mount_read_only #()
  {
    my $self = shift;

    # We call check_mountability() here, because we do not want to modify a
    # file system (i.e., call set_option()) that cannot be mounted
    # anyway. This means that it will be checked twice (once more in
    # mount()), but that should not be too bad.

    $self->check_mountability();
    $self->set_option('ro');
    $self->mount();

    return 1;
  }

# Method: remount
#
# Remounts the file system if necessary. See <needs_remount()>.
#
# Returns:
#
#   True.
sub remount #()
  {
    my $self = shift;

    die 'File system "' . $self->spec() . '" is not mounted' . "\n"
      unless ($self->is_mounted());

    if ($self->needs_remount()) {
      Appartition::Shell::show_progress('Remounting "' . $self->mount_point() . '"');
      Appartition::Shell::show_debug_output('File system is "' . $self->spec() . '"');
      Appartition::Shell::show_debug_output('Effective mount point is "' . $self->effective_mount_point() . '"');

      Appartition::Core::exec_mount($self, 1);

      $self->{'needs_remount'} = 0;
    }

    return 1;
  }

# Method: remount_read_write
#
# Remounts the file system read-write.
#
# Returns:
#
#   True.
sub remount_read_write #()
  {
    my $self = shift;

    die 'File system "' . $self->spec() . '" is not mounted' . "\n"
      unless ($self->is_mounted());

    $self->set_option('rw');
    $self->remount();

    return 1;
  }

# Method: unmount
#
# Unmounts the file system.
#
# Returns:
#
#   True.
sub unmount #()
  {
    my $self = shift;

    die 'File system "' . $self->spec() . '" is not mounted' . "\n"
      unless ($self->is_mounted());

    Appartition::Shell::show_progress('Unmounting "' . $self->mount_point() . '"');
    Appartition::Shell::show_debug_output('File system is "' . $self->spec() . '"');
    Appartition::Shell::show_debug_output('Effective mount point is "' . $self->effective_mount_point() . '"');

    Appartition::Core::exec_umount($self);

    $self->{'is_mounted'} = 0;
    $self->{'needs_remount'} = 0;

    $self->{'stamp'} = undef;

    return 1;
  }

###########################################################################
# Group: Internal Methods
#
# The methods in this group are not meant to be called directly. They are
# used internally by this class.
###########################################################################

# Method: resolve_spec
#
# Resolves the file system's specification. Sets the device file, label,
# and UUID appropriately.
#
# Returns:
#
#   True.
sub resolve_spec #()
  {
    my $self = shift;
    my $spec = $self->spec();

    # TODO: This is specific to Linux and udev. Support for other systems
    # should be added. For example, there are UFS volume labels in FreeBSD,
    # accessible via "/dev/ufs/<label>".

    my $device;
    my $label;
    my $uuid;

    my $symlink;

    if ($spec =~ m'^([A-Za-z]+)=(.*)$'o) {
      # The spec is something like "LABEL=<label>" or "UUID=<uuid>".

      if (uc($1) eq 'LABEL') {
        $label = $2;
        $symlink = '/dev/disk/by-label/' . $label;
      }
      elsif (uc($1) eq 'UUID') {
        $uuid = lc($2);
        $symlink = '/dev/disk/by-uuid/' . $uuid;
      }
      else {
        # Fall back to using the spec as the device file.
        $device = $spec;
      }
    }
    else {
      # Assume the spec is the path to a device file.
      $device = $spec;
    }

    # Read the symbolic link determined above.
    if (defined $symlink and not defined $device and -l $symlink) {
      $device = Appartition::Utilities::read_symbolic_link($symlink);
    }

    # Forget about the device file if it does not exist.
    if (defined $device and not -e $device) {
      $device = undef;
    }

    Appartition::Shell::show_debug_output('Resolved fs spec "' . $spec . '":' . "\n",
                                          '  device = ' . (defined $device ? '"' . $device . '"' : 'undefined') . "\n",
                                          '  label  = ' . (defined $label ? '"' . $label . '"' : 'undefined') . "\n",
                                          '  uuid   = ' . (defined $uuid ? '"' . $uuid . '"' : 'undefined'));

    $self->device($device) if (defined $device);
    $self->label($label) if (defined $label);
    $self->uuid($uuid) if (defined $uuid);

    return 1;
  }

# Method: check_mountability
#
# Checks whether the file system is mountable. Raises an exception if not.
#
# Returns:
#
#   True.
sub check_mountability #()
  {
    my $self = shift;

    die 'File system "' . $self->spec() . '" is already mounted' . "\n"
      if ($self->is_mounted());
    die 'File system "' . $self->spec() . '" is of type "swap"' . "\n"
      if ($self->type() eq 'swap');
    die 'Mount point of file system "' . $self->spec() . '" does not have a leading slash' . "\n",
        'Refusing to mount on "' . $self->mount_point() . '"' . "\n"
      unless ($self->mount_point() =~ m'^/'o);
    die 'No root file system mounted, refusing to mount "' . $self->mount_point() . '"' . "\n"
      if ($self->mount_point() ne '/' and
          not Appartition::Core::filter_mounted_file_systems(Appartition::Core::get_root_file_system_if_available()));
    die 'Refusing to mount another file system on "' . $self->mount_point() . '"' . "\n"
      if (Appartition::Core::filter_mounted_file_systems(Appartition::Core::get_file_system_by_mount_point_if_available($self->mount_point())));

    return 1;
  }

# Method: check_mount_point
#
# Checks the file system's effective mount point. If
# "--create-mount-points" is in effect, attempts to create the mount point
# if necessary. Raises an exception on error.
#
# Returns:
#
#   True.
sub check_mount_point #()
  {
    my $self = shift;
    my $mp = $self->effective_mount_point();

    Appartition::Shell::show_debug_output('Checking effective mount point "' . $self->effective_mount_point() . '"');

    unless (-e $mp) {
      unless ($Appartition::OPTION_VALUES->{'dry-run'}) {
        my $message = 'Mount point "' . $self->mount_point() . '" does not exist';

        if ($Appartition::OPTION_VALUES->{'create-mount-points'}) {
          Appartition::Shell::show_notification($message);

          # Remount all file systems read-write and create the mount
          # point. Since it is hard to find a minimal set of file systems
          # that need to be writable for the mount point to be created
          # successfully, we simply remount them all. If mount points need
          # to be created, the user most likely is about to perform a
          # restore, meaning all file systems will have to be writable,
          # anyway.

          Appartition::Core::remount_all_file_systems_read_write();
          Appartition::Shell::show_progress('Creating it');
          Appartition::Utilities::create_directory_with_parents($mp);
        }
        else {
          die $message . "\n",
              'Rerun with "--create-mount-points" to create it automatically' . "\n";
        }
      }
    }
    elsif (not -d $mp) {
      die 'Mount point "' . $self->mount_point() . '" exists, but is not a directory' . "\n";
    }

    return 1;
  }

1;
